Skip to content

Verify a Cloudinary media url is our asset, not just a Cloudinary url - #185

Merged
renrenmimi merged 1 commit into
mainfrom
fix/cloudinary-url-ownership
Sep 4, 2026
Merged

Verify a Cloudinary media url is our asset, not just a Cloudinary url#185
renrenmimi merged 1 commit into
mainfrom
fix/cloudinary-url-ownership

Conversation

@renrenmimi

Copy link
Copy Markdown
Owner

TRUSTED_MEDIA_URL_HOSTS is ["res.cloudinary.com"], and validateTrustedHttpsUrl checked the protocol and the hostname and nothing else.

A hostname proves the bytes are served by Cloudinary. It does not prove they are ours.

The attack

Register a free Cloudinary account. https://res.cloudinary.com/<their-cloud>/image/upload/... passes a host allowlist unchanged. Call createPostCallable directly with that url — without ever requesting an upload signature — and the entire pipeline is skipped: the 10 MB / 80 MB size caps, the per-user petnote/users/{uid} folder, the signature rate limit.

The worse half is ownership. The asset stays under the attacker's control, so anything that survives a moderation pass can be replaced afterwards, at the same url, on every post, avatar, cover image and check-in referencing it. The same primitive lets one user publish another user's asset url as their own media.

The fix

Two checks, and only for res.cloudinary.com:

  1. the first path segment is our cloud name
  2. the path runs through the petnote/ folder that userFolder() in media.ts creates

api.dicebear.com and lh3.googleusercontent.com are untouched — generated default avatars and Google sign-in photos aren't ours to fingerprint by path, and rejecting them would blank the avatar of every user who never uploaded one.

Two deliberate non-goals

Not enforcing that the folder's uid matches the caller. Family members co-edit a pet's avatar, so uploader and writer are legitimately different people. Reuse inside our own bucket is a far smaller problem than a foreign bucket, and the strict version would break a shipped flow.

isTrustedHttpsUrl stays host-only. It's the read path — getNotificationActor, on triggers as well as callables — and it isn't an authorization decision, it chooses between a stored avatar and a generated default. Ownership is enforced where the url is written. Urls stored before this change are not re-checked; that needs a backfill, not a read-path change.

How the cloud name reaches the validator — and the gap in the tests

Read from process.env.CLOUDINARY_CLOUD_NAME rather than CLOUDINARY_CLOUD_NAME.value(), so shared.ts doesn't have to import a secret param. A bound secret is an env var at runtime.

Eleven callables now bind it — createPost, createPet, updatePet, createMeetup, updateMeetup, addPlace, addLocationPhotos, submitReview, checkIn, ensureUserProfile, updateUserProfile. A callable that reaches the validator without it throws internal rather than degrading to host-only checking, because a silent downgrade would reopen the hole invisibly.

Worth knowing before merge: the emulator tests cannot catch a missing binding. They drive handlers through .run(), which bypasses secret mounting entirely, and setup.ts sets CLOUDINARY_CLOUD_NAME directly — so a callable I forgot would pass CI and fail in production on the first upload. I derived the list by grepping every transitive path to the four validator entry points rather than by testing, and updatePostCallable is deliberately absent (it copies an already-validated petData.avatarUrl and never validates a url itself).

A follow-up worth considering: the cloud name is not actually a secret — it appears in every image url the app serves. Moving it to a plain param alongside CLOUDINARY_FOLDER in platform.ts would remove the binding requirement and this whole class of mistake. That's a config change, so I haven't done it.

Tests

functions/src/__tests__/media-url-ownership.test.ts, 7 tests.

Four fail on the old validator, pass on this one:

× refuses an identical path in someone else's cloud     ← the attack, one line
× refuses our cloud outside the petnote folder
× refuses a foreign cloud in the thumbnail as well as the url
× applies to pet avatars too, not just post media

Three pass on both, as guardrails: our own cloud still accepted, a never-allowed host still rejected, a dicebear avatar still works.

Local run: functions test:emulator 80/80, rules 57/57, lint / build / typecheck:test / typecheck:scripts clean.

Deploy

Functions only — no rules change. The eleven callables gain a secret binding, so they do need redeploying for the check to take effect.

firebase deploy --only functions

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 4, 2026 02:43
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pet-note Ready Ready Preview Sep 4, 2026 7:25am UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 768cf65737

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread functions/src/shared.ts
Comment on lines +197 to +199
if (
!parsed.pathname.startsWith(`/${cloudName}/`) ||
!parsed.pathname.includes(`/${CLOUDINARY_FOLDER}/`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require upload delivery before trusting the folder

When Cloudinary fetch delivery is enabled, callers of createPostCallable and the other media-writing callables can still submit a URL such as https://res.cloudinary.com/<our-cloud>/image/fetch/https://attacker.example/petnote/x.jpg. It satisfies both string checks, but Cloudinary serves an attacker-controlled remote asset without using the signed upload pipeline, preserving the size/rate-limit bypass and post-moderation replacement risk this change is intended to close. Validate the Cloudinary resource and delivery segments as an expected image/upload or video/upload URL and verify petnote in the uploaded asset's public ID rather than accepting it anywhere in the pathname.

Useful? React with 👍 / 👎.

TRUSTED_MEDIA_URL_HOSTS is ["res.cloudinary.com"], and validateTrustedHttpsUrl
checked the protocol and the hostname and nothing else. A hostname proves the
bytes are served by Cloudinary. It does not prove they are ours.

Anyone can register a free Cloudinary account, and
https://res.cloudinary.com/<their-cloud>/image/upload/... passed that check
unchanged. So a client could call createPostCallable directly with a url in
their own bucket — never requesting an upload signature at all — and skip the
entire pipeline: the 10MB/80MB size caps, the per-user petnote/users/{uid}
folder, the signature rate limit.

The worse half is ownership. An asset in someone else's cloud stays under
their control, so anything that passed a moderation pass could be replaced
afterwards at the same url, on every post, avatar, cover image and check-in
that referenced it. Same primitive lets one user publish another user's asset
url as their own media.

Two checks now, and only for res.cloudinary.com: the first path segment is our
cloud name, and the path runs through the petnote/ folder our signing callable
creates. dicebear and lh3.googleusercontent.com are untouched — they serve
generated default avatars and Google sign-in photos, which are not ours to
fingerprint by path, and rejecting them would blank the avatar of every user
who never uploaded one.

NOT enforcing that the folder's uid matches the caller. Family members co-edit
a pet's avatar, so uploader and writer are legitimately different people, and
reuse inside our own bucket is a far smaller problem than a foreign bucket.

The cloud name is read from process.env rather than CLOUDINARY_CLOUD_NAME
.value(), so shared.ts does not have to import a secret param; a bound secret
is an env var at runtime. The eleven callables that validate media urls now
bind it. A callable that reaches the validator without it throws rather than
degrading to host-only checking, because a silent downgrade would reopen the
hole invisibly.

isTrustedHttpsUrl, the read-path variant used by getNotificationActor on
triggers as well as callables, stays host-only. It is not an authorization
decision — it chooses between a stored avatar and a generated default — and
ownership is enforced where the url is written. Urls stored before this are
not re-checked; that needs a backfill, not a read-path change.

Four new tests fail on the old validator and pass on this one. Three pass on
both, as guardrails: our own cloud is still accepted, a never-allowed host is
still rejected, and a dicebear avatar still works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@renrenmimi
renrenmimi force-pushed the fix/cloudinary-url-ownership branch from 768cf65 to 4e99cbe Compare September 4, 2026 07:24
@renrenmimi
renrenmimi merged commit 525b38c into main Sep 4, 2026
6 checks passed
@renrenmimi
renrenmimi deleted the fix/cloudinary-url-ownership branch September 4, 2026 07:33
renrenmimi added a commit that referenced this pull request Sep 6, 2026
renrenmimi added a commit that referenced this pull request Sep 6, 2026
Photo upload has been failing with Invalid Signature. The server signed four
parameters; Cloudinary verifies three.

Cloudinary signs only the upload parameters it recognises and silently drops
the rest. max_file_size is not one it recognises, so including it in the signed
set produced a signature that could never match. Its own error said so — the
String to sign it echoed back listed folder, timestamp and upload_preset, and
nothing else.

This is not a regression from #185, which never touched media.ts or the client
upload path. It dates from whenever max_file_size entered the signature and has
been broken ever since; nobody noticed because nobody had uploaded a photo
through the UI in between.

The comment above the limits is what caused this. It claimed they were
"enforced on the signature itself so a leaked signature can't be reused to
upload a bigger file than we allow" — a reasonable-sounding guarantee that
Cloudinary does not offer. Replaced with what is actually true: the limits are
advisory, they exist to feed the client-side check, and the only enforceable
ceiling is whatever the petnote_image_signed / petnote_video_signed upload
presets have configured in the Cloudinary console.

The client stops sending max_file_size too. Cloudinary ignores it, so it bought
nothing except the appearance that the limit was being transmitted somewhere
meaningful. The size check that produces a useful error before a doomed upload
stays, and the callable still returns maxFileSize to feed it.

Two tests encoded the same false premise and are corrected. One of them —
"signs exactly the parameters it returns" — already carried the comment "if the
handler ever signs a different set of params than it hands back, Cloudinary
rejects every upload". It was right, and it still passed, because it recomputed
the same wrong set the handler used. max_file_size was on both sides of that
comparison and on neither side of Cloudinary's. Its list is now written as the
contract with Cloudinary rather than a mirror of the implementation.

NOT VERIFIED HERE: whether the upload presets actually carry a max file size in
the Cloudinary console. I cannot read that from the repo. If no limit is set
there, then after this change there is no server-side size ceiling at all —
only the advisory client check.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
renrenmimi added a commit that referenced this pull request Sep 6, 2026
renrenmimi added a commit that referenced this pull request Sep 7, 2026
The cloud name is the first path segment of every image URL the app serves. It
is public by construction — anyone who has loaded a single photo already has
it — and storing it in Secret Manager protected nothing.

What it did instead was invent a failure mode. Because it was a secret param,
every callable that validates a media URL had to remember
`secrets: [CLOUDINARY_CLOUD_NAME]`, and one that forgot would pass CI and throw
in production on the first upload. CI cannot catch that: the emulator drives
handlers through .run(), which bypasses secret mounting entirely, and setup.ts
set the variable directly, so the tests saw a value the deployed function would
not have had. #185's own description flagged this and had to derive the list of
eleven bindings by grepping call paths rather than by testing it.

All eleven bindings are gone. The value lives in platform.ts next to
CLOUDINARY_FOLDER, and shared.ts reads it directly instead of reaching into
process.env — which also removes the "misconfigured" branch that existed only
to handle a caller arriving without the binding. There is no longer a way to
arrive without it.

CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET stay in Secret Manager. Those are
the credentials, and media.ts keeps binding them. The truthiness check there
now covers only those two, since the cloud name cannot be empty.

setup.ts stops setting CLOUDINARY_CLOUD_NAME, and the tests compare against the
constant. That is the part that matters beyond tidiness: a test can no longer
pass because the environment supplied something production would not have.

Functions 80/80, build / lint / typecheck:test clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
renrenmimi added a commit that referenced this pull request Sep 7, 2026
The cloud name is the first path segment of every image URL the app serves. It
is public by construction — anyone who has loaded a single photo already has
it — and storing it in Secret Manager protected nothing.

What it did instead was invent a failure mode. Because it was a secret param,
every callable that validates a media URL had to remember
`secrets: [CLOUDINARY_CLOUD_NAME]`, and one that forgot would pass CI and throw
in production on the first upload. CI cannot catch that: the emulator drives
handlers through .run(), which bypasses secret mounting entirely, and setup.ts
set the variable directly, so the tests saw a value the deployed function would
not have had. #185's own description flagged this and had to derive the list of
eleven bindings by grepping call paths rather than by testing it.

All eleven bindings are gone. The value lives in platform.ts next to
CLOUDINARY_FOLDER, and shared.ts reads it directly instead of reaching into
process.env — which also removes the "misconfigured" branch that existed only
to handle a caller arriving without the binding. There is no longer a way to
arrive without it.

CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET stay in Secret Manager. Those are
the credentials, and media.ts keeps binding them. The truthiness check there
now covers only those two, since the cloud name cannot be empty.

setup.ts stops setting CLOUDINARY_CLOUD_NAME, and the tests compare against the
constant. That is the part that matters beyond tidiness: a test can no longer
pass because the environment supplied something production would not have.

Functions 80/80, build / lint / typecheck:test clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants